《时空幻境》的时间倒放玩法!用 Cocos Creator 实现时间回溯
喜欢研究各种有趣效果的「Cocos Star Writer」Nowpaper,之前同我们分享了《饥荒》同款 2.5D 视角的实现和用 RenderTexture 实现小地图与传送门等等,这一次他将在 Cocos Creator 中作物理回溯,实现类《时空幻境》的时间倒放玩法。
第一次接触到电子游戏中的时间倒退玩法,着实被惊艳的表现震惊到了,那种掌控时间的感觉,让人意犹未尽。可是这类游戏并不是很多,其中有个原因就在于,时间倒退的功能会对游戏机制的设计要求极高,程序处理也较为复杂。本文将使用 Cocos Creator 3.3,实现一个很酷的时间回溯的效果。
效果预览
2D 和 3D 的时间倒退技术方案基本上是一样的,本文将使用 3D 物理来实现上图的效果,即用一个球将平台上的方块墙打散,按住一个键产生时间倒退,松开键时间流逝继续。
回溯效果的原理并不复杂。我们只需要记录时间点上的物体状态数据,而记录的次数直接影响了内存量级,一般来说回放只记录小范围的时间段、然后在游戏循环逻辑中倒着播放出来即可。
每个记录间隔多少需要看你的数据设计。我们不需要将每个帧都记录,因为它实在是太快了。一般的做法是记录固定时间间隔上的状态数据,然后做中间插值,而快速倒放甚至都不需要做中间插值,这次的实现效果是没有做中间差值的快速倒放。
准备演示场景
首先搭建一个用来展示的场景,场景并不复杂,包含了一个平台、一个方块墙、一个球的预制体,素材来源于 Cocos Store。
场景准备好之后,我们在世界上创建一个节点,名字叫做 RewindSystem。后面在这个节点下面的所有物体,都会按照规则记录数据状态,它之外的都不会被记录,具体的实现我们等会儿再说。
添加一个 Canvas 节点,加入一个倒退图标,用来标记是否在倒退状态。
现在我们建立一个发射小球的脚本,比如叫 SphereShooter 的组件,进入代码编辑中,加入Canvas、Camera,以及 Prefab 的可引用属性。在 Start 里注册按键事件,通过 on 方法,监听一个 KeyUp 的事件。在事件获取中,通过 KeyCode 判断空格键是否抬起,触发一个发射方法,为了方便,我们将发射封装成一个 shoot 方法。传入点击屏幕的坐标,我们期望是从屏幕中心发射出去,因此传入(0,0)点即可,核心代码如下:
private shoot(x:number,y:number){
const outRay = new geometry.Ray();
this.camera.screenPointToRay(x,y,outRay);
let clone = instantiate(this.sphere);
clone.setPosition(this.camera.node.position);
this.node.addChild(clone);
clone.getComponent(RigidBody).applyImpulse(outRay.d.multiplyScalar(40));
}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407
在这个方法里,通过摄像机 screenPointToRay 方法,取得一个由摄像机为起点、经过屏幕点击点的射线,这个射线就是小球要发射出去的推力向量。而小球的发射位置就直接是相机的位置,将它添加到父节点,最后把推力设置给它。
保存一下返回到 Creator 里,将刚刚写的组件脚本,添加到 RewindSystem 节点上,属性中把 Camera 和小球的 Prefab 引用到组件属性上,运行一下看看效果:按空格键的时候小球发射,球体撞击方块堆砌成的墙面,场景环境发生变化,测试场景就准备好了。
实现回放
现在的实现目标是:按一个按键执行回放,松开按键停止回放。新建一个回放系统组件,在组件脚本中定义 isRecording 属性,来判断控制是否在回放。Start 中注册 KeyDown 和 KeyUp 的事件,通过 KeyCode 值判断按键 R 键是否按下来修改是否回放的成员变量。
记录和回放
在整个回放系统中,我们需要记录所有的可回放物体的重要信息。因此首先定义一个 record 类,里面保存坐标数据和旋转数据。用一个静态方法来设置回放数据,定义一个基于此记录类的数组类 RecordBuffer,它的作用是不断 Push 数据记录,回放的时候利用 Pop 取出最后一个数据,还原给对应的物体,数据结构代码:
class RecordItem{
public vec3:Vec3;
public quat:Quat;
public linearVelocity:Vec3 = new Vec3();
public angularVelocity:Vec3 = new Vec3();
public constructor(rig:RigidBody){
this.vec3 = rig.node.position.clone();
this.quat = rig.node.rotation.clone();
rig.getLinearVelocity(this.linearVelocity);
rig.getAngularVelocity(this.angularVelocity);
}
public static Rewind(rig:RigidBody,item:RecordItem){
rig.node.setPosition(item.vec3);
rig.node.setRotation(item.quat);
rig.setLinearVelocity(item.linearVelocity);
rig.setAngularVelocity(item.angularVelocity);
}
}
class RecordBuffer extends Array<RecordItem>{
}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407
在系统中添加它的 Map 数据结构,Key 用来记录 UUID,Value 则存放 RecordBuffer。在 Start 中,我们启用一个调度,让它在指定的时间间隔中记录这个节点下的每个子节点数据,在逻辑中需要不停地将这些记录数据 push 给 Buffer,时间间隔我设置为1秒内记录30次。
在 update 中,对是否回放进行判定,如果是回放状态(也就是按下了 R 键),遍历记录的 Map 数据,通过 UUID 找到对应的记录缓存,取出最后一个记录点,将数据还原回去。
KINEMATIC 和 DYNAMIC
这里有个重点,倒放时候的游戏时间并没有变化,物理系统仍然运转,此时回放系统在不停设置位置和旋转,因此动态类型物体的物理,可能会因为不停的赋值导致大量的不必要的运算和错误。为了避免这样的问题,在按键按下的时候,设置所有的物体物理类型为运动学刚体 KINEMATIC,按键抬起的时候设置,所有物体物理类型为动力学刚体 DYNAMIC。
// 回放时需要设置
RigidBody.type = ERigidBodyType.KINEMATIC
// 正常时需要设置
RigidBody.type = ERigidBodyType.DYNAMIC
初步成果
将刚刚写的组件脚本添加到回放控制用的节点上,将自由摄像机控制组件添加给主摄像机,并且添加一下倒放 UI 的引用。启动一下,打出几个小球、然后按 R 键看看效果:
怎么样不错吧!回放速度比较快是因为我们在 update 里面执行的修改,之前是1秒30次的记录,而在这里的 update 通常是60帧,也就是每秒60次的赋值修改,因此看起来速度较快。
优化修整
线性速度和旋转(角)速度
就目前来看还有一些瑕疵。第一是回放到一半恢复正常时,物体不会按照之前的物理状态运行。在上面的初步效果预览中,物体倒回到一半时,它会在半空落下或者按照恢复时候的物理行进,而我们的期望是,回放前是什么样,回放后恢复的时候还是什么样子,如下图:
为了达到这个效果,这里我们除了需要记录位置和旋转信息,还要记录刚体的线性速度和旋转速度,因此需要改造一下记录数据结构,在记录上做一些处理,添加线性速度和角速度的记录数据,恢复的时候将这些数据一并还原。仅仅这样还不够,最重要的是最后一次的记录缓存到一个 Map 中,在按键抬起的时候,将最后一次的记录给物体恢复一下物理数据,完成之后保存返回到 Creator 运行,现在物理在回放前和回放后几乎保持一致了。
核心代码:
private lastRecords = new Map<string,RecordItem>();
OnKeyUp(event:EventKeyboard){
if(event.keyCode == KeyCode.KEY_R){
this.isRewind = false;
this.playbackIcon.active = false;
for(let node of this.node.children){
this.changeRigidBodyType(node.getComponent(RigidBody),ERigidBodyType.DYNAMIC);
const item = this.lastRecords.get(node.uuid);
if(item){
RecordItem.Rewind(node.getComponent(RigidBody),item);
this.lastRecords.delete(node.uuid);
}
}
}
}
// 完整代码请移步Store : https://store.cocos.com/app/detail/3407
收回生成额外物体
第二个问题是小球倒回后不会消失,这是因为小球是通过 Prefab 创建的,它没有之前的数据,因此只需要在队列为空的时候判断一下是不是小球即可,如果是的话直接销毁。
结语
到此时间回溯效果就已经完成了,这是一个小而有趣的技术点。其实无论对于 2D 还是 3D 游戏,要想完美实现回放,还需要很多工作。回放点记录数值的策略,取决于你的游戏结构设计,可以通过计数调用、过度插值、动画倒放等方式优化这个功能,但是考虑的问题也会很多,这里就不展开说了。
视频版教程发布在 B 站:
https://www.bilibili.com/video/BV1zF41187qe
完整源码见Cocos Store:
https://store.cocos.com/app/detail/3407
论坛讨论帖:
https://forum.cocos.org/t/topic/125655
如果您觉这个很酷,还请点赞收藏支持,希望本文对您有用。也欢迎关注我的 B 站,后续将会有更多 Cocos Creator 游戏开发分享。我是 Nowpaper,一个混迹游戏行业的老爸,我们下次再见。
>> 点击查看嘉宾及演讲主题
12月18日下午14:00,Cocos 开发者沙龙「厦门站」将在厦门香格里拉酒店举办。Cocos 引擎、亚马逊云科技、网易易盾、青瓷游戏、风领科技围绕引擎技术与生态、游戏开发与发行等内容,为各位开发者准备了一场干货盛宴。
报名来到现场的小伙伴,还将获得「Cocos 最新定制周边大礼包」,人手一份哦!点击文末【阅读原文】或扫描下方二维码免费报名吧↓
>> 开发者报名通道